<?php
/**
 * Zend Framework
 *
 * LICENSE
 *
 * This source file is subject to the new BSD license that is bundled
 * with this package in the file LICENSE.txt.
 * It is also available through the world-wide-web at this URL:
 * http://framework.zend.com/license/new-bsd
 * If you did not receive a copy of the license and are unable to
 * obtain it through the world-wide-web, please send an email
 * to license@zend.com so we can send you a copy immediately.
 *
 * @category   Zend
 * @package    Zend_Auth
 * @subpackage Zend_Auth_Adapter
 * @copyright  Copyright (c) 2005-2011 Zend Technologies USA Inc. (http://www.zend.com)
 * @license    http://framework.zend.com/license/new-bsd     New BSD License
 * @version    $Id: Ldap.php 23775 2011-03-01 17:25:24Z ralph $
 */

/**
 * @see Zend_Auth_Adapter_Interface
 */
require_once 'Zend/Auth/Adapter/Interface.php';

/**
 * @category   Zend
 * @package    Zend_Auth
 * @subpackage Zend_Auth_Adapter
 * @copyright  Copyright (c) 2005-2011 Zend Technologies USA Inc. (http://www.zend.com)
 * @license    http://framework.zend.com/license/new-bsd     New BSD License
 */
class Zend_Auth_Adapter_Ldap implements Zend_Auth_Adapter_Interface {
	
	/**
	 * The Zend_Ldap context.
	 *
	 * @var Zend_Ldap
	 */
	protected $_ldap = null;
	
	/**
	 * The array of arrays of Zend_Ldap options passed to the constructor.
	 *
	 * @var array
	 */
	protected $_options = null;
	
	/**
	 * The username of the account being authenticated.
	 *
	 * @var string
	 */
	protected $_username = null;
	
	/**
	 * The password of the account being authenticated.
	 *
	 * @var string
	 */
	protected $_password = null;
	
	/**
	 * The DN of the authenticated account. Used to retrieve the account entry on request.
	 *
	 * @var string
	 */
	protected $_authenticatedDn = null;
	
	/**
	 * Constructor
	 *
	 * @param  array  $options  An array of arrays of Zend_Ldap options
	 * @param  string $username The username of the account being authenticated
	 * @param  string $password The password of the account being authenticated
	 * @return void
	 */
	public function __construct(array $options = array(), $username = null, $password = null) {
		$this->setOptions ( $options );
		if ($username !== null) {
			$this->setUsername ( $username );
		}
		if ($password !== null) {
			$this->setPassword ( $password );
		}
	}
	
	/**
	 * Returns the array of arrays of Zend_Ldap options of this adapter.
	 *
	 * @return array|null
	 */
	public function getOptions() {
		return $this->_options;
	}
	
	/**
	 * Sets the array of arrays of Zend_Ldap options to be used by
	 * this adapter.
	 *
	 * @param  array $options The array of arrays of Zend_Ldap options
	 * @return Zend_Auth_Adapter_Ldap Provides a fluent interface
	 */
	public function setOptions($options) {
		$this->_options = is_array ( $options ) ? $options : array ();
		return $this;
	}
	
	/**
	 * Returns the username of the account being authenticated, or
	 * NULL if none is set.
	 *
	 * @return string|null
	 */
	public function getUsername() {
		return $this->_username;
	}
	
	/**
	 * Sets the username for binding
	 *
	 * @param  string $username The username for binding
	 * @return Zend_Auth_Adapter_Ldap Provides a fluent interface
	 */
	public function setUsername($username) {
		$this->_username = ( string ) $username;
		return $this;
	}
	
	/**
	 * Returns the password of the account being authenticated, or
	 * NULL if none is set.
	 *
	 * @return string|null
	 */
	public function getPassword() {
		return $this->_password;
	}
	
	/**
	 * Sets the passwort for the account
	 *
	 * @param  string $password The password of the account being authenticated
	 * @return Zend_Auth_Adapter_Ldap Provides a fluent interface
	 */
	public function setPassword($password) {
		$this->_password = ( string ) $password;
		return $this;
	}
	
	/**
	 * setIdentity() - set the identity (username) to be used
	 *
	 * Proxies to {@see setUsername()}
	 *
	 * Closes ZF-6813
	 *
	 * @param  string $identity
	 * @return Zend_Auth_Adapter_Ldap Provides a fluent interface
	 */
	public function setIdentity($identity) {
		return $this->setUsername ( $identity );
	}
	
	/**
	 * setCredential() - set the credential (password) value to be used
	 *
	 * Proxies to {@see setPassword()}
	 *
	 * Closes ZF-6813
	 *
	 * @param  string $credential
	 * @return Zend_Auth_Adapter_Ldap Provides a fluent interface
	 */
	public function setCredential($credential) {
		return $this->setPassword ( $credential );
	}
	
	/**
	 * Returns the LDAP Object
	 *
	 * @return Zend_Ldap The Zend_Ldap object used to authenticate the credentials
	 */
	public function getLdap() {
		if ($this->_ldap === null) {
			/**
			 * @see Zend_Ldap
			 */
			require_once 'Zend/Ldap.php';
			$this->_ldap = new Zend_Ldap ();
		}
		
		return $this->_ldap;
	}
	
	/**
	 * Set an Ldap connection
	 *
	 * @param Zend_Ldap $ldap An existing Ldap object
	 * @return Zend_Auth_Adapter_Ldap Provides a fluent interface
	 */
	public function setLdap(Zend_Ldap $ldap) {
		$this->_ldap = $ldap;
		
		$this->setOptions ( array ($ldap->getOptions () ) );
		
		return $this;
	}
	
	/**
	 * Returns a domain name for the current LDAP options. This is used
	 * for skipping redundant operations (e.g. authentications).
	 *
	 * @return string
	 */
	protected function _getAuthorityName() {
		$options = $this->getLdap ()->getOptions ();
		$name = $options ['accountDomainName'];
		if (! $name)
			$name = $options ['accountDomainNameShort'];
		return $name ? $name : '';
	}
	
	/**
	 * Authenticate the user
	 *
	 * @throws Zend_Auth_Adapter_Exception
	 * @return Zend_Auth_Result
	 */
	public function authenticate() {
		/**
		 * @see Zend_Ldap_Exception
		 */
		require_once 'Zend/Ldap/Exception.php';
		
		$messages = array ();
		$messages [0] = ''; // reserved
		$messages [1] = ''; // reserved
		

		$username = $this->_username;
		$password = $this->_password;
		
		if (! $username) {
			$code = Zend_Auth_Result::FAILURE_IDENTITY_NOT_FOUND;
			$messages [0] = 'A username is required';
			return new Zend_Auth_Result ( $code, '', $messages );
		}
		if (! $password) {
			/* A password is required because some servers will
             * treat an empty password as an anonymous bind.
             */
			$code = Zend_Auth_Result::FAILURE_CREDENTIAL_INVALID;
			$messages [0] = 'A password is required';
			return new Zend_Auth_Result ( $code, '', $messages );
		}
		
		$ldap = $this->getLdap ();
		
		$code = Zend_Auth_Result::FAILURE;
		$messages [0] = "Authority not found: $username";
		$failedAuthorities = array ();
		
		/* Iterate through each server and try to authenticate the supplied
         * credentials against it.
         */
		foreach ( $this->_options as $name => $options ) {
			
			if (! is_array ( $options )) {
				/**
				 * @see Zend_Auth_Adapter_Exception
				 */
				require_once 'Zend/Auth/Adapter/Exception.php';
				throw new Zend_Auth_Adapter_Exception ( 'Adapter options array not an array' );
			}
			$adapterOptions = $this->_prepareOptions ( $ldap, $options );
			$dname = '';
			
			try {
				if ($messages [1])
					$messages [] = $messages [1];
				$messages [1] = '';
				$messages [] = $this->_optionsToString ( $options );
				
				$dname = $this->_getAuthorityName ();
				if (isset ( $failedAuthorities [$dname] )) {
					/* If multiple sets of server options for the same domain
                     * are supplied, we want to skip redundant authentications
                     * where the identity or credentials where found to be
                     * invalid with another server for the same domain. The
                     * $failedAuthorities array tracks this condition (and also
                     * serves to supply the original error message).
                     * This fixes issue ZF-4093.
                     */
					$messages [1] = $failedAuthorities [$dname];
					$messages [] = "Skipping previously failed authority: $dname";
					continue;
				}
				
				$canonicalName = $ldap->getCanonicalAccountName ( $username );
				$ldap->bind ( $canonicalName, $password );
				/*
                 * Fixes problem when authenticated user is not allowed to retrieve
                 * group-membership information or own account.
                 * This requires that the user specified with "username" and optionally
                 * "password" in the Zend_Ldap options is able to retrieve the required
                 * information.
                 */
				$requireRebind = false;
				if (isset ( $options ['username'] )) {
					$ldap->bind ();
					$requireRebind = true;
				}
				$dn = $ldap->getCanonicalAccountName ( $canonicalName, Zend_Ldap::ACCTNAME_FORM_DN );
				
				$groupResult = $this->_checkGroupMembership ( $ldap, $canonicalName, $dn, $adapterOptions );
				if ($groupResult === true) {
					$this->_authenticatedDn = $dn;
					$messages [0] = '';
					$messages [1] = '';
					$messages [] = "$canonicalName authentication successful";
					if ($requireRebind === true) {
						// rebinding with authenticated user
						$ldap->bind ( $dn, $password );
					}
					return new Zend_Auth_Result ( Zend_Auth_Result::SUCCESS, $canonicalName, $messages );
				} else {
					$messages [0] = 'Account is not a member of the specified group';
					$messages [1] = $groupResult;
					$failedAuthorities [$dname] = $groupResult;
				}
			} catch ( Zend_Ldap_Exception $zle ) {
				
				/* LDAP based authentication is notoriously difficult to diagnose. Therefore
                 * we bend over backwards to capture and record every possible bit of
                 * information when something goes wrong.
                 */
				
				$err = $zle->getCode ();
				
				if ($err == Zend_Ldap_Exception::LDAP_X_DOMAIN_MISMATCH) {
					/* This error indicates that the domain supplied in the
                     * username did not match the domains in the server options
                     * and therefore we should just skip to the next set of
                     * server options.
                     */
					continue;
				} else if ($err == Zend_Ldap_Exception::LDAP_NO_SUCH_OBJECT) {
					$code = Zend_Auth_Result::FAILURE_IDENTITY_NOT_FOUND;
					$messages [0] = "Account not found: $username";
					$failedAuthorities [$dname] = $zle->getMessage ();
				} else if ($err == Zend_Ldap_Exception::LDAP_INVALID_CREDENTIALS) {
					$code = Zend_Auth_Result::FAILURE_CREDENTIAL_INVALID;
					$messages [0] = 'Invalid credentials';
					$failedAuthorities [$dname] = $zle->getMessage ();
				} else {
					$line = $zle->getLine ();
					$messages [] = $zle->getFile () . "($line): " . $zle->getMessage ();
					$messages [] = str_replace ( $password, '*****', $zle->getTraceAsString () );
					$messages [0] = 'An unexpected failure occurred';
				}
				$messages [1] = $zle->getMessage ();
			}
		}
		
		$msg = isset ( $messages [1] ) ? $messages [1] : $messages [0];
		$messages [] = "$username authentication failed: $msg";
		
		return new Zend_Auth_Result ( $code, $username, $messages );
	}
	
	/**
	 * Sets the LDAP specific options on the Zend_Ldap instance
	 *
	 * @param  Zend_Ldap $ldap
	 * @param  array $options
	 * @return array of auth-adapter specific options
	 */
	protected function _prepareOptions(Zend_Ldap $ldap, array $options) {
		$adapterOptions = array ('group' => null, 'groupDn' => $ldap->getBaseDn (), 'groupScope' => Zend_Ldap::SEARCH_SCOPE_SUB, 'groupAttr' => 'cn', 'groupFilter' => 'objectClass=groupOfUniqueNames', 'memberAttr' => 'uniqueMember', 'memberIsDn' => true );
		foreach ( $adapterOptions as $key => $value ) {
			if (array_key_exists ( $key, $options )) {
				$value = $options [$key];
				unset ( $options [$key] );
				switch ($key) {
					case 'groupScope' :
						$value = ( int ) $value;
						if (in_array ( $value, array (Zend_Ldap::SEARCH_SCOPE_BASE, Zend_Ldap::SEARCH_SCOPE_ONE, Zend_Ldap::SEARCH_SCOPE_SUB ), true )) {
							$adapterOptions [$key] = $value;
						}
						break;
					case 'memberIsDn' :
						$adapterOptions [$key] = ($value === true || $value === '1' || strcasecmp ( $value, 'true' ) == 0);
						break;
					default :
						$adapterOptions [$key] = trim ( $value );
						break;
				}
			}
		}
		$ldap->setOptions ( $options );
		return $adapterOptions;
	}
	
	/**
	 * Checks the group membership of the bound user
	 *
	 * @param  Zend_Ldap $ldap
	 * @param  string    $canonicalName
	 * @param  string    $dn
	 * @param  array     $adapterOptions
	 * @return string|true
	 */
	protected function _checkGroupMembership(Zend_Ldap $ldap, $canonicalName, $dn, array $adapterOptions) {
		if ($adapterOptions ['group'] === null) {
			return true;
		}
		
		if ($adapterOptions ['memberIsDn'] === false) {
			$user = $canonicalName;
		} else {
			$user = $dn;
		}
		
		/**
		 * @see Zend_Ldap_Filter
		 */
		require_once 'Zend/Ldap/Filter.php';
		$groupName = Zend_Ldap_Filter::equals ( $adapterOptions ['groupAttr'], $adapterOptions ['group'] );
		$membership = Zend_Ldap_Filter::equals ( $adapterOptions ['memberAttr'], $user );
		$group = Zend_Ldap_Filter::andFilter ( $groupName, $membership );
		$groupFilter = $adapterOptions ['groupFilter'];
		if (! empty ( $groupFilter )) {
			$group = $group->addAnd ( $groupFilter );
		}
		
		$result = $ldap->count ( $group, $adapterOptions ['groupDn'], $adapterOptions ['groupScope'] );
		
		if ($result === 1) {
			return true;
		} else {
			return 'Failed to verify group membership with ' . $group->toString ();
		}
	}
	
	/**
	 * getAccountObject() - Returns the result entry as a stdClass object
	 *
	 * This resembles the feature {@see Zend_Auth_Adapter_DbTable::getResultRowObject()}.
	 * Closes ZF-6813
	 *
	 * @param  array $returnAttribs
	 * @param  array $omitAttribs
	 * @return stdClass|boolean
	 */
	public function getAccountObject(array $returnAttribs = array(), array $omitAttribs = array()) {
		if (! $this->_authenticatedDn) {
			return false;
		}
		
		$returnObject = new stdClass ();
		
		$returnAttribs = array_map ( 'strtolower', $returnAttribs );
		$omitAttribs = array_map ( 'strtolower', $omitAttribs );
		$returnAttribs = array_diff ( $returnAttribs, $omitAttribs );
		
		$entry = $this->getLdap ()->getEntry ( $this->_authenticatedDn, $returnAttribs, true );
		foreach ( $entry as $attr => $value ) {
			if (in_array ( $attr, $omitAttribs )) {
				// skip attributes marked to be omitted
				continue;
			}
			if (is_array ( $value )) {
				$returnObject->$attr = (count ( $value ) > 1) ? $value : $value [0];
			} else {
				$returnObject->$attr = $value;
			}
		}
		return $returnObject;
	}
	
	/**
	 * Converts options to string
	 *
	 * @param  array $options
	 * @return string
	 */
	private function _optionsToString(array $options) {
		$str = '';
		foreach ( $options as $key => $val ) {
			if ($key === 'password')
				$val = '*****';
			if ($str)
				$str .= ',';
			$str .= $key . '=' . $val;
		}
		return $str;
	}
}
